Skip to content

Add Tenuo authorization contrib module#1447

Open
aimable100 wants to merge 2 commits intotemporalio:mainfrom
tenuo-ai:main
Open

Add Tenuo authorization contrib module#1447
aimable100 wants to merge 2 commits intotemporalio:mainfrom
tenuo-ai:main

Conversation

@aimable100
Copy link
Copy Markdown

Summary

Adds temporalio.contrib.tenuo, a SimplePlugin that wires Tenuo warrant-based authorization into Temporal workflows. Agents (workflows) carry signed warrants specifying which tools (activities) they can call and with what argument constraints. Sub-agents (child workflows) receive attenuated warrants — capabilities can only shrink, never expand.

  • TenuoPlugin — registers client interceptor (warrant header injection), worker interceptors (PoP signing + authorization verification), and sandbox passthrough for the tenuo native extension.
  • Thin adapter — only 3 symbols exported from the contrib module (TenuoPlugin, TENUO_PLUGIN_NAME, ensure_tenuo_workflow_runner). All other types are imported from tenuo.temporal, matching the pattern established by openai_agents and other contrib modules.
  • No private imports — all tenuo.temporal internals used by the plugin are exposed through public lazy-loaded names.

Files

File Lines Purpose
temporalio/contrib/tenuo/__init__.py 27 Public API (3 exports)
temporalio/contrib/tenuo/_plugin.py 180 TenuoPlugin SimplePlugin subclass
temporalio/contrib/tenuo/README.md 371 Documentation with multi-agent delegation example
tests/contrib/tenuo/test_tenuo.py 464 Unit tests + live integration tests
tests/contrib/tenuo/test_tenuo_replay.py 243 Record-and-replay determinism tests
pyproject.toml +2 tenuo optional dependency

Replay safety

Replay determinism is verified at two levels:

  1. Static analysis — source inspection confirms workflow.now() (not time.time()), no datetime.now(), no os.urandom/random/uuid4, no time.sleep, no threading.Thread.
  2. Live record-and-replay — workflows execute against a local Temporal server, history is captured via fetch_history(), and a fresh TenuoPlugin instance replays via Replayer. Tests cover single-tool and multi-tool (sequential PoP ordering) scenarios.

Integration tests

  • test_authorized_activity_succeeds — full warrant → PoP → authorization flow
  • test_start_workflow_authorizedstart_workflow_authorized returns a handle
  • test_unauthorized_activity_is_non_retryable — unauthorized tool call produces WorkflowFailureError with ApplicationError(non_retryable=True)
  • test_duplicate_registration_raises — same plugin instance on two workers raises RuntimeError

Test plan

  • pytest tests/contrib/tenuo/test_tenuo.py -v — unit + integration tests
  • pytest tests/contrib/tenuo/test_tenuo_replay.py -v — replay determinism tests
  • ruff check temporalio/contrib/tenuo/ tests/contrib/tenuo/ — no lint errors

Adds `temporalio.contrib.tenuo`, a SimplePlugin integration for
Tenuo warrant-based authorization in Temporal workflows.

The plugin (`TenuoPlugin`) wires client interceptors, worker
interceptors, and workflow sandbox passthrough in a single line:

    from temporalio.contrib.tenuo import TenuoPlugin
    plugin = TenuoPlugin(config)
    client = await Client.connect("localhost:7233", plugins=[plugin])

Key design decisions:
- Thin adapter: only TenuoPlugin, TENUO_PLUGIN_NAME, and
  ensure_tenuo_workflow_runner are exported from the contrib module.
  All other types (TenuoPluginConfig, EnvKeyResolver, etc.) are
  imported directly from tenuo.temporal.
- No private imports: all tenuo.temporal internals used by the plugin
  are exposed through public lazy-loaded names.
- No re-exports of external package types, matching the pattern
  established by openai_agents and other contrib modules.

Files:
- temporalio/contrib/tenuo/__init__.py — public API (3 exports)
- temporalio/contrib/tenuo/_plugin.py — TenuoPlugin SimplePlugin subclass
- temporalio/contrib/tenuo/README.md — multi-agent delegation example
- tests/contrib/tenuo/test_tenuo.py — unit + live integration tests
- tests/contrib/tenuo/test_tenuo_replay.py — record-and-replay tests
- pyproject.toml — tenuo optional dependency
Add Tenuo authorization contrib module
@aimable100 aimable100 requested a review from a team as a code owner April 14, 2026 21:52
@tconley1428
Copy link
Copy Markdown
Contributor

I don't think it is likely that we are willing to accept this. We welcome folks using Temporal as a part of their solution, but including it in the SDK's contrib comes with an implication of our maintenance and ownership of the solution. From a technical perspective, you are welcome to create a plugin external to the SDK repo, and we can have a discussion about partnership. If you reach out in our community slack, I can put you in touch with the folks running AI partnership.

@aimable100
Copy link
Copy Markdown
Author

Thanks for the note. This was submitted through Temporal's AI Partner Program — I was invited and completed the submission form. Happy to move to an external plugin if that's the preferred path for partners too. Will follow up with the team to confirm.

@jssmith
Copy link
Copy Markdown
Contributor

jssmith commented Apr 14, 2026

@aimable100 - thank you for preparing this plugin. I will leave this PR open so that our team can provide feedback. You should plan to move it to one of your repositories, though.

@0xbrainkid

This comment was marked as low quality.

Copy link
Copy Markdown
Contributor

@DABH DABH left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plugin appears to be following the recommended strategy of using SimplePlugin's interface. The plugin is also performing authorization by intercepting activities - interceptors are indeed designed to perform actions like this as seen in the OpenTelemetryPlugin and BraintrustPlugin, so no concerns there. Replay testing is being taken seriously here, which is nice to see.

One overall concern is the naming/shipping strategy here. Tenuo appears to already ship a Temporal plugin (https://tenuo.ai/temporal). The present plugin imports a bunch of stuff (including TenuoPlugin) from tenuo.temporal, so it seems like the present PR is really a thin wrapper/adapter around what's already been published. Can you explain how the present PR is different than the existing plugin, and whether both need to exist? On naming, Tenuo's README on tenuo-ai/tenuo uses the name TenuoTemporalPlugin, while the integration docs page uses TenuoPlugin, and the PR adds a third binding temporalio.contrib.tenuo.TenuoPlugin - three names in three places could be a little confusing.

I left a few inline comments and suggestions in the code below. Happy to continue the conversation and keep iterating. Thank you for your efforts!

Comment thread temporalio/contrib/tenuo/__init__.py
Comment thread temporalio/contrib/tenuo/_plugin.py
Comment thread temporalio/contrib/tenuo/_plugin.py
Comment thread temporalio/contrib/tenuo/_plugin.py
Comment thread temporalio/contrib/tenuo/__init__.py
Comment thread tests/contrib/tenuo/test_tenuo_replay.py
Comment thread tests/contrib/tenuo/test_tenuo_replay.py
Comment thread tests/contrib/tenuo/test_tenuo_replay.py
Comment thread tests/contrib/tenuo/test_tenuo_replay.py
Comment thread temporalio/contrib/tenuo/_plugin.py
aimable100 added a commit to tenuo-ai/tenuo that referenced this pull request Apr 23, 2026
## Summary

Addresses worker-side feedback from the Temporal team (DABH) on

[temporalio/sdk-python#1447](temporalio/sdk-python#1447).

**Plugin (`tenuo-python/tenuo/temporal_plugin.py`)**
- No longer mutates the user's `TenuoPluginConfig`; works on a shallow
copy so two workers sharing a config stay isolated.
- Registers Tenuo's domain exceptions (`TenuoContextError`,
`PopVerificationError`, `TemporalConstraintViolation`, `WarrantExpired`,
`ChainValidationError`, `KeyResolutionError`, `LocalActivityError`) as
`workflow_failure_exception_types` on SDKs that support it.
- Preload failures log at `ERROR` with the resolver class name;
`EnvKeyResolver` preload failure raises `ConfigurationError` (no safe
`os.environ` fallback in the sandbox).
- `ensure_tenuo_workflow_runner` emits a `UserWarning` plus a logger
warning when given `UnsandboxedWorkflowRunner` (Tenuo still works — the
user is just opting out of Temporal's own determinism guardrails, which
is a legitimate choice for debugging), and warns for unknown custom
runners.
- Duplicate-registration error now points at
`Client.connect(plugins=[plugin])` inheritance instead of advising
one-plugin-per-worker.

**Plugin-confusion rename (`tenuo.temporal.TenuoPlugin` →
`TenuoWorkerInterceptor`)**
- The old name was a Temporal SDK `WorkerInterceptor`, not a Temporal
SDK `Plugin`, and its resemblance to
`tenuo.temporal_plugin.TenuoTemporalPlugin` caused real
misconfigurations (e.g. `Worker(plugins=[TenuoPlugin(...)])` silently
accepting an unusable argument).
- New canonical name: `tenuo.temporal.TenuoWorkerInterceptor`.
- Backward compat: `tenuo.temporal.TenuoPlugin` is still importable as a
deprecated alias and emits a `DeprecationWarning` on first resolution;
scheduled for removal in a future beta. Most users register
`TenuoTemporalPlugin` via `Client.connect(plugins=[plugin])` and are
unaffected.
- Updated all internal usages, tests, examples (5 files), and docs.
Added an "About the names" callout table in `docs/temporal.md` and a
"renamed from" breadcrumb in `docs/temporal-reference.md`.
- New unit test asserts the alias warns and resolves to the new class.

**Tests**
- `DictKeyResolver` raises `KeyResolutionError` instead of `ValueError`.
- 7 new unit tests in `tests/adapters/test_temporal_plugin.py` cover
every plugin-side change above, plus the deprecation-alias test.

**Deferred to follow-ups**
- Making `ensure_tenuo_workflow_runner` private — useful public escape
hatch for advanced users; keep public.
- Replay-time negative tests (tampered history, rotated trusted roots,
clock-boundary). Initial attempts revealed that the current plugin
architecture does not re-verify activity PoP during replay — activities
don't re-execute, and the workflow inbound interceptor only stashes
headers without re-checking. Designing meaningful replay-safety tests
requires plumbing changes and should be scoped as its own task.

## Test plan
- [x] `uv run pytest tests/adapters/test_temporal_plugin.py` — 33 passed
(incl. new deprecation test).
- [x] `uv run pytest tests/adapters/test_temporal.py
tests/adapters/test_transparent_interceptor.py
tests/adapters/test_temporal_integration.py
tests/e2e/test_temporal_replay.py` — 166 passed.
- [x] `uv run pytest tests/e2e/test_temporal_e2e.py
tests/e2e/test_temporal_replay.py` — 61 passed.
- [x] `uv run pytest tests/security/test_security_contracts.py
tests/security/test_integration_invariants.py` — 117 passed, 22 skipped.
- [x] `uvx ruff check` clean on modified files.
- [x] `mypy tenuo/temporal/__init__.py tenuo/temporal/_interceptors.py
tenuo/temporal_plugin.py` — no errors.
- [x] All 5 Temporal examples byte-compile.
@aimable100
Copy link
Copy Markdown
Author

Thanks @DABH. Per-comment replies inline.

Plan is to withdraw this PR, per @jssmith's guidance. Shipping
from tenuo-ai/tenuo as TenuoTemporalPlugin (plugin) and
TenuoWorkerInterceptor (interceptor). TenuoPlugin is gone.

One bonus finding worth flagging: your tampered-history
suggestion led me to test malformed bytes at the activity ingress
instead (replay doesn't re-run inbound interceptors; detail in the
inline reply). That exposed a latent bug: Warrant.from_bytes
raises DeserializationError, which the interceptor wasn't
catching, so bad bytes escaped uncaught and Temporal treated authz
failures as retryable. Now
non-retryable CHAIN_INVALID with a DENY audit event.

References:

Thanks again for the thorough pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants